C++ 事件分发器:线程安全队列与死锁防护
事件分发器(Dispatcher)是 GUI 框架、消息中间件客户端、游戏引擎里反复出现的构件:多个外部线程向队列注入事件,内部工作线程从队列取出事件分发给处理器。它是典型的生产者-消费者模型,线程安全与死锁防护都围绕共享队列展开。本文整理一套完整的设计要点,核心原则是"减少共享、同步有界、顺序一致",适合自己动手实现事件循环或消息队列的开发者参考。
基础:互斥锁 + 条件变量
生产者端
void Dispatcher::pushEvent(Event event) {
{
std::lock_guard<std::mutex> lock(m_queueMutex);
m_eventQueue.push(std::move(event));
} // lock_guard 析构,自动释放锁
m_queueCondVar.notify_one(); // 先释放锁,再通知
} 两个关键点:
- RAII 加锁(
std::lock_guard):即使push抛异常,锁也能安全释放,不会造成永久死锁; - 先释放锁再
notify_one:若先通知后释放,被唤醒的线程会立即尝试拿锁却拿不到(锁仍被当前线程持有),白白多一次上下文切换;先释放再通知,唤醒的线程可直接获得锁开始工作。
消费者端
void Dispatcher::run() {
while (m_isRunning) { // atomic<bool>
Event event;
{
std::unique_lock<std::mutex> lock(m_queueMutex);
m_queueCondVar.wait(lock, [this] {
return !m_eventQueue.empty() || !m_isRunning;
});
if (!m_isRunning && m_eventQueue.empty()) break;
event = std::move(m_eventQueue.front());
m_eventQueue.pop();
} // 锁在这里释放
processEvent(event); // 【关键】在锁外执行事件处理
}
} 三个关键点:
- 用
std::unique_lock:condition_variable::wait需要在等待时解锁、唤醒后重新加锁,lock_guard做不到; - 带谓词的
wait:防御虚假唤醒(spurious wakeup)——操作系统可能在没有 notify 的情况下唤醒等待线程,谓词重新检查条件,不满足就继续睡; - 锁外处理事件:临界区内只做"移动数据"这类轻量操作,
processEvent放在锁外。处理一个事件时,其他生产者线程可以继续 push,不被阻塞——极大缩短锁持有时间,是并发性能最重要的一处优化。
分场景选择同步原语
| 原语 | 适用场景 | 说明 |
|---|---|---|
std::atomic | 简单状态标志、计数器(如运行标志) | 开销最小,配 memory_order_acquire/release 精确控制可见性 |
std::mutex + condition_variable | 保护复杂结构的独占访问、线程间同步(任务队列) | 通用选择 |
std::shared_mutex(C++17) | 读多写少的资源(观察者/处理器注册列表) | 读端 shared_lock 并发遍历,写端 unique_lock 独占 |
| 无锁结构 | 极致性能路径 | 不自研,用经过验证的第三方实现(如 moodycamel::ConcurrentQueue)或中间件内置结构 |
观察者列表是 shared_mutex 的典型场景:注册/注销(写)频率低,遍历通知(读)频率极高。用普通 mutex 会让多个读线程串行化,读写锁让读并发、只在写时短暂阻塞所有线程。
死锁防护的三条规则
单一职责锁:一个 Dispatcher 只拥有一把互斥锁,且它只保护事件队列。类内部不存在多把锁,就不存在内部锁序死锁。
禁止在事件处理中回调 Dispatcher:
processEvent()绝不允许再调用任何要拿同一把锁的公共方法(如pushEvent):
// 反例:处理事件时又去拿同一把锁 → 死锁
void processEvent(const Event& event) {
if (event.type == "error") {
pushEvent(Event{"shutdown"}); // 错误!
}
} 确有需要时走异步路径:放入另一个无锁队列,由别的线程代为投递。
- 明确的关闭协议:
void Dispatcher::stop() {
m_isRunning.store(false); // 原子标志,无需锁
m_queueCondVar.notify_all(); // 可能有多个工作线程
} 谓词里包含 || !m_isRunning,保证队列为空时线程也能被 notify_all 唤醒、看到退出信号并安全结束——避免关闭时线程永远睡在 wait 上。
两把锁必须按不同顺序获取的场景,可用 std::lock(mtx1, mtx2) 一次性加锁(内部用死锁避免算法)配合 std::scoped_lock 管理;多线程死锁已经发生时的通用调试手段,是用 GDB 的 thread apply all bt 抓全量调用栈,找出互相等待的锁。
小结
这套设计的要点可以压缩成三句话:临界区内只搬数据、处理逻辑放锁外(缩短持锁时间);一把锁只管一件事、处理中不许回调(杜绝锁序死锁);等待用带谓词的 wait、退出靠原子标志加 notify_all(防御虚假唤醒与关不掉的线程)。在此基础上,按场景换用 shared_mutex 或经过验证的无锁结构,覆盖绝大多数事件分发的并发需求。